Skip to content

bun:test: call a function passed to .resolves/.rejects and wait on its result - #41069

Closed
robobun wants to merge 8 commits into
robobun/97c4d3a2/expect-await-thenablefrom
robobun/a5b8c3a4/expect-fn-receiver
Closed

robobun wants to merge 8 commits into
robobun/97c4d3a2/expect-await-thenablefrom
robobun/a5b8c3a4/expect-fn-receiver

Conversation

@robobun

@robobun robobun commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • process_promise calls a callable receiver, then waits on the return value through thenable_to_wait_for (bun:test: settle thenables through their own then() in .resolves/.rejects #40996). A synchronous throw propagates as the test error, as in Jest. The usage error takes Jest's wording: Expected a promise or a function returning a promise.
  • The mismatch messages print the settled value (Received promise that resolved: "x"), not the receiver, which printed as Promise { <resolved> }.
  • resolves in bun-types unwraps a function receiver's return type, so expect(async () => 1).resolves.toBe(1) type-checks.
  • Verified: test/js/bun/test/expect.test.js (new test resolves/rejects on a function returning a promise, function cases for expect.rejectsTo / expect.resolvesTo), test/integration/bun-types/fixture/test.ts. Also the other expect suites in test/js/bun/test.

Background

Notes
  • Jest semantics (packages/expect/src/index.ts, makeResolveMatcher / makeRejectMatcher): typeof actual === 'function' ? actual() : actual, then isPromise(actualWrapper) (any object or function with a callable then). The usage error prints the original receiver, not its return value. This PR keeps both.
  • A function receiver that returns a non-thenable (() => 4) is the usage error, with Received: [Function], as in Jest.
  • In the asymmetric matcher path process_promise runs with silent = true. A thrown error from the received function stays pending on the VM, Expect_readFlagsAndProcessPromise returns false, and the RETURN_IF_EXCEPTION that bun:test: settle thenables through their own then() in .resolves/.rejects #40996 adds in matchAsymmetricMatcher propagates it instead of letting .not flip the FAIL to PASS. The new expect.rejectsTo / resolvesTo propagate a sync throw from a received function test covers all four combinations.
  • The settled value is printed with the same console formatter the other matchers use for Received:, so an Error prints with its stack, as expect(new Error("x")).toBe(1) does today.
  • rejects stays Matchers<unknown>. The resolves type is Matchers<Awaited<T extends (...args: any[]) => infer R ? R : T>>. Without it, expect(() => Promise.resolve(1)).resolves.toBe(1) is TS2769: No overload matches this call. The fixture checks Matchers<number> for a promise and for a function receiver.
  • Fail before: on the bun:test: settle thenables through their own then() in .resolves/.rejects #40996 branch the new expect.test.js test fails with Expected promise on its first assertion, and the fixture reports four type errors without the .d.ts change.
  • Suites run on the debug build: expect.test.js 417 pass, bun-test.test.ts 5 pass, expect-extend.test.js, jest-extended.test.js, spyMatchers.test.ts, expect-assertions.test.ts, mock-fn.test.js, bun-types.test.ts 20 pass. BUN_JSC_validateExceptionChecks=1 on the new tests reports nothing.
  • Self-reviewed: 2 concerns raised. Addressed: the change was first folded into bun:test: settle thenables through their own then() in .resolves/.rejects #40996, which had excluded this feature from its scope. It is now this stacked PR, so the hang fix and the Jest parity feature are reviewed on their own. Not addressed here: the settled-promise direct read in thenable_to_wait_for skips an overridden then() on an already settled subclass. That helper belongs to bun:test: settle thenables through their own then() in .resolves/.rejects #40996, which documents the choice, and bun:test: wait on a Promise subclass through its own then() #41008 adds a shared JSPromise::awaitable that bun:test: settle thenables through their own then() in .resolves/.rejects #40996 plans to adopt.

[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 5 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect.test.js
bun test v1.4.1 (a6c4cc276)

test/js/bun/test/expect.test.js:
(pass) expect() > () [300.70ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [2.20ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.46ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.24ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.25ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.23ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.23ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.23ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.25ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.23ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.23ms]
(pass) expect() > toBe() > expect(0).toBe(false) == false [1.25ms]
(pass) expect() > toBe() > expect(0).toBe("") == false [0.44ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.34ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.29ms]
(pass) e
... (truncated)

release without fix: 2 skipped
bun test v1.4.1-canary.1 (6f9e95b81)

test/js/bun/test/expect.test.js:
(pass) expect() > () [0.64ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.02ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(-0).toBe(-0) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true
(pass) expect() > toBe() > expect({}).toBe({}) == true
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true
(pass) expect() > toBe() > expect(0).toBe(false) == false [0.02ms]
(pass) expect() > toBe() > expect(0).toBe("") == false
(pass) expect() > toBe() > expect(0).toBe(-0) == false
(pass) expect() > toBe() > expect(0).toBe(-0) == false
(pass) expect() > toBe() > expect(1).toBe(2) == false
(pass) expect() > toBe() > expect(1).toBe(true) == false
(pass) expect() > toBe() > expect(1).toBe("1") == false
(pass) expect() > toBe() > expect(Infinity).toBe(-Infinity) == false
(pass) expect() > toBe() > expect("foo
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect.test.js
bun test v1.4.1 (a6c4cc276)

test/js/bun/test/expect.test.js:
(pass) expect() > () [303.08ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [1.85ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.43ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.27ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.24ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.23ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.26ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.23ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.23ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.26ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.24ms]
(pass) expect() > toBe() > expect(0).toBe(false) == false [1.22ms]
(pass) expect() > toBe() > expect(0).toBe("") == false [0.48ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.31ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.30ms]
(pass) e
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 597ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[1/6] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
packages/bun-types/test.d.ts               | 10 ++--
 src/runtime/test_runner/expect.rs          | 14 +++--
 test/integration/bun-types/fixture/test.ts | 19 +++++++
 test/js/bun/test/expect.test.js            | 83 +++++++++++++++++++++++++++++-
 4 files changed, 118 insertions(+), 8 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
packages/bun-types/test.d.ts                    1      1      0
src/runtime/test_runner/expect.rs               2      4      0
test/integration/bun-types/fixture/test.ts      1      3      0
test/js/bun/test/expect.test.js                 4      5      0

…ects

expect(value).resolves and .rejects waited on the internal JSPromise state
and never called the value's then(). A Promise subclass that starts its
work inside an overridden then(), such as Bun.SQL's Query or Bun.$'s
ShellPromise, never settled, so the matcher spun the event loop forever
and the test timeout could not interrupt it. A plain thenable was refused
with "Expected promise".

Adopt a pending promise or a thenable through a fresh native promise,
which runs the spec resolve steps and calls the value's own then(), the
same as await does. JSC skips that call for a promise with the built-in
then. A settled promise is still read directly. The same path covers
expect(fn).toThrow() on a returned promise and a custom matcher that
returns a promise.
Let the adopter's resolve steps do the single Get(then), as await does.
A value that is not a thenable fulfills the adopter at once, which is the
signal for the Expected promise error. A then getter that throws now
rejects instead of throwing from the matcher, the same as await.

The hang guard test also asserts that the child exited on its own.
…s result

Jest calls a function receiver and waits on what it returns. `.rejects`
has done this since jest 26 (jestjs/jest#9817), `.resolves` since jest
30.0.4 (jestjs/jest#15714). The usage error now uses Jest's wording,
"Expected a promise or a function returning a promise", and the
settlement mismatch messages print the settled value instead of the
receiver, which is not a promise when it is a thenable or a function.

The `resolves` type unwraps a function receiver's return type, so
`expect(async () => 1).resolves.toBe(1)` type-checks.

The function receiver handling comes from #32944.

Fixes #16144
… value

CI runs the matchers with colors on, so the message reads
`Received promise that resolved: \x1b[31m"settled to this"\x1b[0m`.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 104e0d5, a test-only change. CI runs the matchers with colors on, so the mismatch message reads Received promise that resolved: <red>"settled to this"<reset>. The three new assertions on that message now allow a color code between the label and the value. The first CI run failed only on those assertions, on every lane. FORCE_COLOR=1 bun bd test test/js/bun/test/expect.test.js reproduces the failure with the old pattern and passes with the new one.

@robobun

robobun commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:06 PM PT - Aug 31st, 2026

✅ @robobun, your commit 104e0d548de019c73203c0ec43c598a168055e85 passed in Build #108924! 🎉


🧪   To try this PR locally:

bunx bun-pr 41069

That installs a local version of the PR into your bun-41069 executable, so you can run:

bun-41069 --bun

@robobun

robobun commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Main already has the change in this PR's title. #44833 (9e76acc) added it, and #16144 is closed.

This branch cannot land as it is. It has conflicts with main in four files. It also builds on the blocking wait in the test runner, which #44833 removed. Its base PR, #40996, is closed.

Three parts of this PR are not on main. Each of them can be proposed again on top of main's code.

  1. The mismatch message with the settled value. This PR prints Received promise that resolved: "x". Main prints the received value: Promise { <resolved> } for a promise (pinned here), and [Function] for a function.
  2. Jest's wording for the usage error. This PR prints Expected a promise or a function returning a promise. Main prints Expected promise (pinned here).
  3. A function under expect.resolvesTo / expect.rejectsTo. On main, expect({ a: () => Promise.resolve("x") }).toEqual({ a: expect.resolvesTo.stringContaining("x") }) fails. That path does not call a function (expect.rs:660).

@robobun robobun closed this Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant